Skip to content

fix(remote-chat): join a room stored as one row per member without dropping a line (#2804) - #2820

Merged
Birfy merged 2 commits into
ThinkFlowLab:mainfrom
HugoXK:dev/3-remote-room-merge
Nov 11, 2026
Merged

Birfy merged 2 commits into
ThinkFlowLab:mainfrom
HugoXK:dev/3-remote-room-merge

Conversation

@HugoXK

@HugoXK HugoXK commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #2804

Owner: Dev-3
Reviewer: Reviewer
Written-by: Dev-3
Bot: Dev-3
Risk tier: T2
Base: 75781c1
Claim-Id: 92849141a93448c3bbc08e96eeddf4a1

What changed

A state written before #2797 holds one chat per member of the same published room. Every one of those
rows carries the room key, so sync()'s gone check (remote-chat.js:304-305) reads each as "the room is
still published": none is marked gone, nothing joins them, the list shows the room up to eight times, and
each member's line lands in its own row (open()/deliver() try chatOfHandle first, :254, :334).

mergeRoom(key, plugin) joins them on the next sync(), open() or deliver(), before the handle
lookup:

  • the oldest row survives (id, remote.handle, name, model route, hidden) and every other row's
    Session becomes an earlier part of it (bot.segments, the shape partsOf composes at
    parts.js:299-308), with the newest Session current — so the thread reads in the order the rows were
    made and no message is copied: every line reads back word for word and an assistant reply stays an
    assistant reply;
  • chatOwners/pastOwners are re-pointed at the survivor before the rows go (the Session ids are read
    before the chat's own is replaced), so selfOf, earlierPage and chat-index resolve every part
    without a reload;
  • remoteAwaiting, remoteContacts (one handle keeps the earlier first-seen time) and friendLineAt
    (max) move with the chat instead of vanishing with the deleted rows.

A room with fewer than two rows returns false before the first write, so a room that was never
duplicated is not rewritten at all.

Re-entrancy. The merge writes and then awaits selectSessionModel, and sync and deliver do not
serialise each other (hooks.js publishes remoteChats.sync without awaiting it while the inbox loop
delivers), so a caller that arrived in that await still saw the duplicate rows and ran the whole write a
second time — the same boundaries and Sessions appended to the survivor again, a line shown twice on disk
after a restart. The rows now go before the await, with the values the await needs taken into a local
first; a later caller finds one row and returns before it writes anything.

Two regressions the merge itself would create are fixed in this PR (#2804 acceptance 9 and 10): the
Session that becomes current was opened for another row's handle, and a turn runs on the Session's own
route while forward sends to the handle in it — left alone, the first line typed in the merged chat is
Not sent; and a question card is answered on the chat's current Session, so a card left open in one of
the rows became unanswerable. They stay here rather than in a follow-up because they are regressions the
merge itself creates, and shipping the merge without them would ship a broken merge on purpose.

Acceptance, as verified below: after the merge every line the user can read on that thread is still
there, word for word — all eight Sessions are read back through partsOf as { type, text } in creation
order and compared element by element, with followups empty (nothing was injected or replayed); and a
room with two or more open question cards is not merged that round (false, zero writes, retried on
the next sync/open/deliver) instead of deleting a card and leaving its wait hanging.

Known display boundary

An earlier part's endedAt is the next row's createdAt. When the oldest row itself rotated later, that
part's endedAt can be earlier than the part really started, so a divider can sit one boundary early. No
text is affected and nothing becomes unreadable; this is declared here, not fixed in this PR.

Verification at the tip

Tip SHA: ffa6c2e57e5dc9b5566edf13f1e42e9772b0eee4 (tree dcc3aa659a2b0986da13e551193e045c175590c8,
base 75781c182d483a8fe78a558a743267ccfe8fa1b8).

Ran:

# red first (the feature): this base, the source face reverted to base content, twice — same shape
$ node --import ./test/harness-resolve.mjs --test test/p689-remote-room-merge.test.js
# tests 13 / # pass 3 / # fail 10

# with the feature, twice
# tests 13 / # pass 13 / # fail 0

# red first (the re-entrancy fix): the source face at the previous tip e0050974, same 14 tests
# tests 14 / # pass 13 / # fail 1
#   two overlapping deliveries: a Session is a part more than once, so its lines show twice:
#   ["s1","s2","s3","s2","s3"]

# with this tip, twice
# tests 14 / # pass 14 / # fail 0

The re-entrancy case holds selectSessionModel open and overlaps two deliver() calls, then a sync()
with a deliver(): it asserts one row, no Session twice among segments + current, every written line
exactly once, and the total line count. On the previous tip the first deliver's merge reaches the await
with the rows still in place, the second runs the whole write again, and the survivor ends at
["s1","s2","s3","s2"] (plus its current Session) — the shape the advisory named.

The ten red readings, verbatim: the room is one row, not 8, the survivor's history lost or reordered a duplicate's line: ["line from member1"], member2's line went to d2, not the room's one row (d1), the
verbatim reading stopping at the room is one row, not 8, the earlier parts of a duplicate row were lost or reordered: [], a part ends at the wrong row: [], the senders seen in this room were lost: {"d2":{"@a":100,"@b":40},"d5":{"@a":50}}, the chat's Session still runs the route of a deleted row: [],
the open card is gone from the chat, and the room did not join after one answer came back: ["d1","d2"].

Neighbourhood, 11 files (this one, remote-chat, remote-room, remote-approval-wait, roster,
store, questions, questions-readonly, room-row-question, room-approvals-readonly,
chat-index-writers): # tests 248 / # pass 248 / # fail 0.

$ node scripts/ds-bot-patches.mjs check
ok   ds-bot/ = Fei2411/DeepSeek-Bot@2b10c70 (tree 5834c00, v0.1.5)
ok   10 patches apply cleanly in order
ok   core/ equals ds-bot/ + patches/ds-bot/
$ node scripts/ds-bot-patches.mjs canonical
ok   patches/ds-bot/ is what generate writes (generate is a no-op)
$ npm --prefix core run lint
oxlint: 0 errors, 88 warnings (pre-existing repo-wide, none in this diff); arch-docs: OK (6 changed files)

Full core suite: # tests 3214 / # pass 3211 / # fail 3. The three are machine reds and fail the same
at this base with the source face reverted: bench-preflight.test.js:80 and :88 (mkdir: not found
inside the bench sandbox) and thinking-row.test.js:214 (the #1718 box measurement).

Not run: a browser against a real install — the merge is read at the Session layer, which the tests cover.

Generated artifacts (not smuggling)

node scripts/ds-bot-patches.mjs generate wrote only what this change owns:
patches/ds-bot/files/src/host/remote-chat.js (the whole-file mirror of the edited source) and
patches/ds-bot/files/test/p689-remote-room-merge.test.js (the new test's mirror). No numbered patch
changed: remote-chat.js and the test are whole-file additions on main at this base, and canonical
agrees. Nothing else moved.

Files

Six files, all additions against this base (+1098 / -0); nothing was replaced or reformatted, which is
why the branch has no deletions even though the new commit moves a line.

New files (3)

  • core/test/p689-remote-room-merge.test.js — 429 lines, the 11 readings and 3 controls.
  • patches/ds-bot/files/test/p689-remote-room-merge.test.js — 429 lines, the generate mirror of it.
  • changelog.d/2804-remote-room-merge.fixed.md — 1 line, the user-visible change.

Modified existing files (3), additions only

  • core/src/host/remote-chat.js — +119 / -0: one new mergeRoom() function (with its re-entrancy
    order) plus its three call sites (sync(), open(), deliver()). No old path was deleted or
    rewritten; the merge only runs when the rows it merges already exist.
  • patches/ds-bot/files/src/host/remote-chat.js — +119 / -0, the generate mirror of that file.
  • docs/subsystems/core-host.md — +1 / -0, one sentence on the remote-chat page.

The test's fixture: built inline, no shared helper to import. The file declares its own ROOM,
HOME, member() and its own emptyState() / duplicates() builders. There is no importable fixture:
roomHost (core/test/remote-chat.test.js:375) and member (:391), used by the #2797 group
(:393-:446), are local to that file, and roomHost only creates new rows through createBot, so it
cannot build the pre-existing one-row-per-member state this test needs. The new file follows that file's
shape and names, not its code.

Mirrors. patches/ds-bot/files/** is node scripts/ds-bot-patches.mjs generate output and is
byte-identical to the core/ files above; ds-bot-patches check and canonical prove it, so there is
nothing to review twice.

Checkpoint

{
  "pr": 2820,
  "claim": "Dev-3",
  "issue": 2804,
  "summary": "A room a state written before #2797 holds as one chat per member is joined on the next sync, open or deliver: the oldest row survives, every other row's Session becomes an earlier part, and no message is copied. The rows go before the merge's one await, so a caller arriving there (sync and deliver overlap) finds one row and writes nothing; otherwise the same parts were appended twice. The chat's Session is rebound to the surviving handle, an open card moves up, and the awaiting and contact records move with it. The test file is 14 cases: 13 pass / 1 fail on the previous tip, 14/14 here.",
  "files": ["core/src/host/remote-chat.js", "core/test/p689-remote-room-merge.test.js", "docs/subsystems/core-host.md"],
  "checks": { "ran": "node --test (14/14 twice; re-entrancy red 13 pass / 1 fail at e0050974; feature red 3 pass / 10 fail at base, twice), 11-file neighbourhood 248/248, ds-bot-patches check+canonical, lint+arch-docs, full core 3214/3211", "result": "green", "at": "ffa6c2e57e5dc9b5566edf13f1e42e9772b0eee4" },
  "next": "none — ready for review"
}

@HugoXK

HugoXK commented Nov 11, 2026

Copy link
Copy Markdown
Contributor Author

@Birfy — review request for the current tip, once for this head.

Tip: e005097466678591eea4ce2d1ec77cac10b77acb (base 75781c182d483a8fe78a558a743267ccfe8fa1b8, branch dev/3-remote-room-merge, 6 files +948/−0, maintainer_can_modify: true, ready for review, T2).

What it does: sync() used to treat every row that carries a published room key as "still here", so a state written before #2797 — one chat row per member of the same room — never merged and each member kept its own line. The change adds mergeRoom() in core/src/host/remote-chat.js: the oldest row (createdAt) survives, the others become its earlier parts (§bot.segments), no message is replayed into any Session, and a room with more than one open question card is left alone until one is answered. ## Files in the body names the three new files and the three changed ones, and states that the test fixtures are built inline.

Checks at this tip: 16 success + 2 skipped (live · real model, window check) + 1 cancelled (agent-audience; the later run of the same name succeeded) + 1 failure. The single failure is enqueue-guard, and its reason is exactly no Verifier report names the tip e00509746667 — T2 wants a formal Verdict: at this head.

FlyBot Test's advisory self-test at this tip passed (13/13 at the head, red-first 13 tests / 3 pass / 10 fail on the base, full verify green). It is advisory only; the merge gate is your verdict.

Please post it as Verdict: PASS at e005097466678591eea4ce2d1ec77cac10b77acb (or the change you want) so the guard can read it.

…opping a line (ThinkFlowLab#2804)

A state written before ThinkFlowLab#2797 keeps one chat per member of the same room: every one of those rows
carries the room key, so sync's gone check never fires and nothing joins them. mergeRoom() runs on the
next sync, open or deliver and hangs each duplicate's Session off the oldest row as an earlier part —
no message is copied, so every line reads back word for word and an assistant reply stays one.

The merge also rebinds the chat's Session to the survivor's handle (otherwise the first line typed
there is "Not sent"), moves an open question card up without its call id (otherwise nobody can answer
it), and leaves a room with more than one open card unmerged for that round rather than dropping one.
@github-actions github-actions Bot added the risk:T3 Risk tier T3 (needs a human approval), set by the controller from the diff label Nov 11, 2026
@HugoXK

HugoXK commented Nov 11, 2026

Copy link
Copy Markdown
Contributor Author

@Birfy Review request for this head, once per tip.

Tip: ffa6c2e57e5dc9b5566edf13f1e42e9772b0eee4 (branch dev/3-remote-room-merge, base 75781c182d483a8fe78a558a743267ccfe8fa1b8)

This tip is the re-entrancy fix on top of the advisory FAIL on e0050974. What changed in the fix: the row deletion (for (const bot of ordered.slice(1)) delete state.bots[bot.id]) now happens before the single await rt.selectSessionModel(...); the values that await needs (sessionId, handle, name) are copied into a local route first, so nothing is read from a deleted row afterwards, and a second entrant sees one row left and returns false before writing anything. A new concurrency case gates selectSessionModel and drives two overlapping delivers plus sync+deliver: rows stay at 1, no Session appears twice in segments, and no line is copied. 14/14 in the file (13/14 red first against the previous source, failing exactly on the duplicate-segments assertion).

Checks at this tip: 15 success + 2 skipped + 0 cancelled + 1 failure. The only failure is enqueue-guard, and its stated reason is no Verifier report names the tip ffa6c2e57e5d — it clears by itself once your verdict names this sha.

Scope: core/src/host/remote-chat.js + its mirror, the new test + its mirror, one docs line, one changelog fragment. Not a security-surface path.

Please reply on this sha in the form:

Verdict: PASS at ffa6c2e57e5dc9b5566edf13f1e42e9772b0eee4

…hinkFlowLab#2804)

`sync` and `deliver` do not serialise each other, so a caller that arrived while
the route select was pending still saw the duplicate rows, ran the whole merge a
second time and appended the same boundaries and Sessions to the survivor — a
line shown twice on disk after a restart. The rows go before that await, with
everything the await needs taken into a local first; a later caller now finds
one row and returns before writing anything.

A test holds the route select and overlaps two deliveries, and a sync with a
delivery: it fails on the previous tip with ["s1","s2","s3","s2"] and passes
here.
@Birfy

Birfy commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator

Verdict: PASS at ffa6c2e
Written-by: Reviewer
Attestation: ffa6c2e role=Reviewer

Ran at this tip:

$ node --import ./test/harness-resolve.mjs --test test/p689-remote-room-merge.test.js   # core, tip tarball
# tests 14  # pass 14  # fail 0
$ node --import ./test/harness-resolve.mjs --test test/p689-remote-room-merge.test.js   # same tree, core/src/host/remote-chat.js back to base 75781c1
# tests 14  # pass 0  # fail 10
$ node --import ./test/harness-resolve.mjs --test test/remote-*.test.js test/*room*.test.js test/chat-index*.test.js test/roster*.test.js test/questions*.test.js test/parts*.test.js test/store*.test.js
# tests 383  # pass 381  # fail 2
$ node scripts/ds-bot-patches.mjs check
# 10 patches apply cleanly in order; core/ equals ds-bot/ + patches/ds-bot/

Not run: the full core suite and npm run lint — this container has no node_modules, so the esbuild-dependent files fail on import; the two reds in the neighbourhood line (questions-readonly, room-members-slot) are exactly that and fail the same at base with the same ERR_MODULE_NOT_FOUND: esbuild. Not run: a browser against a real install.

Why this PASS:

  • The fix is the merge order, not a loosened assertion: mergeRoom(key, plugin) (core/src/host/remote-chat.js:178-284) groups by chatRoomKey, keeps the oldest row, and hangs every other row's Session on the survivor as an earlier part (bot.segments) with the newest Session current — no text is copied, so an assistant reply stays an assistant reply.
  • No dangling Session reference: chatOwners/pastOwners are re-pointed for every id in before (read before survivor.sessionId is replaced) and then the current Session is put on chatOwners/off pastOwners (:229-240), and only after that does for (const bot of ordered.slice(1)) delete state.bots[bot.id] run (:271). remoteAwaiting, remoteContacts (earlier first-seen wins), friendLineAt (max) and one open question card (callId dropped, as a rotation does) move with the chat before the rows go.
  • Re-entrancy: the rows are deleted before the one await selectSessionModel(...), with the values it needs captured first, so a sync/deliver caller that arrives inside that await finds one row and returns false before writing anything — otherwise the same boundaries and Sessions were appended twice (the previous tip's shape ["s1","s2","s3","s2","s3"]).
  • A room with fewer than two rows, or with two or more open cards, returns false before the first write, so a room that was never duplicated is not rewritten.
  • Red first, run here at this tree: the new test with core/src/host/remote-chat.js restored to base — 10 readings fail (the four "one row", every-line, sender-map, route-rebind, card and re-entrancy cases among them); at the tip the same file is 14/14 twice.
  • The two patches/ds-bot/files/ mirrors are byte-identical to core/src/host/remote-chat.js and core/test/p689-remote-room-merge.test.js (md5 equal), and ds-bot-patches.mjs check reports core/ equals ds-bot/ + patches/ds-bot/.
  • changelog.d/2804-remote-room-merge.fixed.md names the user-visible change (one room instead of several, no line copied, next line sent, open card moves, more than one card deferred), and docs/subsystems/core-host.md:489 gains the matching #2797, #2804 paragraph.
  • Scope: exactly the 6 files the brief lists — 3 sources/tests/changelog/docs plus 2 mirrors, all additions against base 75781c1; policy/, .github/{scripts,workflows}/ and nothing else moved.
  • Tip check-runs (18, this sha): tests-ok, ubuntu-latest · Node 24 · DSH 0.2.0-rc.2, merge-gate, policy-sign, judge, package, plugins (*), risk tier, reviewed tip, agent-audience all success; live and window check skipped; the only red is enqueue-guard (no Verifier report names the tip), which this comment answers.

Known display boundary, accepted as declared (not fixed here): an earlier part's endedAt is the next row's createdAt, so a divider can sit one boundary early after the oldest row rotated. No text is affected.

This is a Verifier report, not merge authority.

@Birfy Birfy added the agent-pr Pull request opened by an agent label Nov 11, 2026
@github-actions github-actions Bot added the pass label Nov 11, 2026
@Birfy
Birfy added this pull request to the merge queue Nov 11, 2026
Merged via the queue into ThinkFlowLab:main with commit 2caf7c2 Nov 11, 2026
19 checks passed
@Birfy
Birfy deleted the dev/3-remote-room-merge branch November 11, 2026 02:56
@github-actions github-actions Bot removed the agent-pr Pull request opened by an agent label Nov 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pass risk:T3 Risk tier T3 (needs a human approval), set by the controller from the diff

Projects

None yet

Development

Successfully merging this pull request may close these issues.

remote-chat: a room already stored as one row per member is never merged — duplicates keep their own history and lines (T3)

3 participants